#!/usr/bin/env python

from __future__ import print_function

import os, sys

# Try to reset encoding to utf-8
# Note: This is incompatible with pypy
# Note: In addition to PYTHONIOENCODING=UTF-8, this also enables command-line arguments to be decoded properly.
import platform, locale
sys_encoding = locale.getdefaultlocale()[1] or 'UTF-8'
if platform.python_implementation() != "PyPy":
    try:
        reload(sys).setdefaultencoding(sys_encoding)
    except:
        pass

import logging, stat, argparse, json, threading

from errno import ENOENT, ENOTDIR
from time import time

from fuse import FUSE, FuseOSError, Operations, LoggingMixIn

import dxpy
dxpy.USER_AGENT += " dxfs"

if not hasattr(__builtins__, 'bytes'):
    bytes = str

def _get_size(obj):
    if obj["class"] == "gtable":
        # HACK to get gtables to sort of work for now. TODO: Add enough bytes to account for all the tabs and newlines in the tsv output.
        return obj.get('size', 0) * 2
    else:
        return obj.get('size', 0)

class DXInode(object):
    DIR  = 'dir'
    FILE = 'file'
    LINK = 'link'

    def __init__(self, type, name, mode, uid, gid, ctime=None, mtime=None, size=0, dxid=None, describe=None):
        debug("New inode", type, name, mode, uid, gid)
        self.type = type
        self.name = name     # file name
        self.dev  = 0        # device ID (if special file)
        self.mode = mode     # protection and file-type
        self.uid  = uid      # user ID of owner
        self.gid  = gid      # group ID of owner
        self.size = size

        self.dxid = dxid
        self.describe = describe

        self.now()

        # Extended Attributes
        self.xattr = {}

        # Data
        if stat.S_ISDIR(mode):
            # {node name -> node}
            self.child_nodes = {}
        else:
            self.data = ''

        if ctime:
            self.ctime = ctime
        if mtime:
            self.mtime = mtime

        self._handler = None

    @property
    def handler(self):
        if self._handler is None and self.dxid is not None:
            self._handler = dxpy.get_handler(self.dxid)
        return self._handler

    @handler.setter
    def handler(self, h):
        self._handler = h

    def reload(self):
        self._handler = dxpy.get_handler(self.dxid)

    def now(self):
        self.atime = time()   # time of last access
        self.mtime = self.atime    # time of last modification
        self.ctime = self.atime    # time of last status change

    def stat(self):
        debug("Constructing stat for", self.name)
        try:
            stat = dict(
                st_mode  = self.mode,       # protection bits
                st_ino   = 0,               # inode number
                st_dev   = self.dev,        # device
                st_nlink = 2,               # number of hard links
                st_uid   = self.uid,        # user ID of owner
                st_gid   = self.gid,        # group ID of owner
                st_size  = self.size,       # size of file, in bytes
                st_atime = self.atime,      # time of most recent access
                st_mtime = self.mtime,      # time of most recent content modification
                st_ctime = self.ctime,      # platform dependent; time of most recent metadata change on Unix, or the time of creation on Windows
            )
        except Exception as e:
            debug("EXCEPTION", e)
        debug("returning stat for", self.name)
        return stat

    def child(self, path):
        debug(self.name, 'asked for child', path)
        match = None
        if self.type == DXInode.DIR:
            nodes = path.split('/')
            if nodes[0] in self.child_nodes:
                child = self.child_nodes[nodes[0]]
                if len(nodes) > 1:
                    match = child.child('/'.join(nodes[1:]))
                else:
                    match = child
                    debug("Found child", match.name)
        return match

    def read(self, offset, length):
        debug("Reading from", self.name, offset, length)
        #stat.st_atime = time.now()
        if self.dxid and self.dxid.startswith('file'):
            if self.handler.state != 'closed':
                self.reload()
                if self.handler.state != 'closed':
                    return ''
            self.handler.seek(offset)
            return self.handler.read(length)
        elif self.dxid and self.dxid.startswith('gtable'):
            # TODO: make this less naive
            if self.handler.state != 'closed':
                self.reload()
            rows = ""
            for row in self.handler:
                rows += ("\t".join(map(unicode, row))+"\n").encode('utf-8')
                if len(rows) >= offset+length:
                    break
            return rows[offset:offset+length]
        elif self.dxid and self.dxid.startswith('record'):
            return json.dumps(self.handler.get_details(), encoding='utf-8')[offset:offset+length]
        else:
            return self.data[offset:offset+length]

    def write(self, offset, data):
        if self.dxid and self.dxid.startswith('file'):
            # TODO: write seek
            self.handler.write(data)
        else:
            raise NotImplementedError()

        # TODO: update size of self
        self.now()
        return len(data)

    def truncate(self, length):
        debug("Truncating", self.name)
        self.data = self.data[0:length]
        self.now()


class DXFS(LoggingMixIn, Operations):
    def __init__(self, project_id, refresh_interval=5):
        self.fd = 0
        self.uid = os.getuid()
        self.gid = os.getgid()
        self.project_id = project_id
        self.root = DXInode(DXInode.DIR, 'root', 0755 | stat.S_IFDIR, self.uid, self.gid)
        self.last_created = 0
        self.project_mtime = 0
        self.refresh_interval = refresh_interval
        self.initialized_once = threading.Event()

    def wait_until_initialized(self):
        # Run a less expensive check that doesn't need to acquire a
        # lock, so we can possibly short-circuit the real wait() call,
        # which does.
        if not self.initialized_once.is_set():
            self.initialized_once.wait()

    def init(self, root_path):
        ''' This method is used both for initializing the filesystem and for reloading it upon modification.
        '''

        # The event initialized_once is used to prevent any external
        # calls to filesystem methods from being processed until we have
        # populated the filesystem for the first time. This prevents the
        # user from seeing the filesystem in an empty state. All
        # filesystem methods (except trivial ones like statfs) should
        # call self.wait_until_initialized() before proceeding. As a
        # corollary, any filesystem method foo that is liable to be
        # called from init() should be factored out into a 'foo' method
        # for public consumption and a '_foo' method for internal use,
        # where only the 'foo' method calls wait_until_initialized, and
        # init() only calls the '_foo' (non-waiting) version.

        if self.project_id.startswith('project-'):
            self.project = dxpy.DXProject(self.project_id)
        elif self.project_id.startswith('container-'):
            self.project = dxpy.DXContainer(self.project_id)

        proj_desc = self.project.describe(input_params={"folders": True})

        self.project_mtime = proj_desc['modified']

        debug("Populating folders...")
        folders = proj_desc['folders']
        folders.sort(key=lambda item: (len(item), item))
        debug("Found", len(folders), "folders")

        # This is where the old tree gets blown away (when reloading).
        self.root = DXInode(DXInode.DIR, 'root', 0755 | stat.S_IFDIR, self.uid, self.gid)

        for path in folders:
            if path == '/':
                continue
            self._mkdir(str(path), 0755 | stat.S_IFDIR, make_remote=False)

        debug("Populated", len(folders), "folders")
        debug("Populating data objects...")

        for i in dxpy.search.find_data_objects(project=self.project_id, describe=True):
            self._new_dataobject(i["describe"])

        debug('Finished init')

        if not self.initialized_once.is_set():
            self.initialized_once.set()

        threading.Timer(self.refresh_interval, self.refresh).start()

    def _new_dataobject(self, dataobject, add_missing_folders=False):
        dataobject["folder"] = str(dataobject["folder"])
        dataobject["name"] = str(dataobject["name"]).replace("/", "").replace("\0", "")

        if add_missing_folders:
            folderpath = ''
            for path_element in dataobject["folder"].split('/')[1:]:
                folderpath += path_element
                if not self.root.child(folderpath):
                    self._mkdir(str('/' + folderpath), 0755 | stat.S_IFDIR, make_remote=False)
                folderpath += '/'

        if dataobject["class"] == "applet" or (dataobject["class"] == "record" and "pipeline" in dataobject["types"]):
            mode = 0755
        else:
            mode = 0644

        path = os.path.join(dataobject["folder"], dataobject["name"])
        self._create(path,
                     mode,
                     ctime=dataobject['created']/1000,
                     mtime=dataobject['modified']/1000,
                     size=_get_size(dataobject),
                     dxid=dataobject['id'],
                     describe=dataobject)
        self.last_created = max(self.last_created, dataobject['created'])

    def refresh(self):
        new_mtime = self.project.describe(input_params={"fields": {"modified": True}})['modified']

        if self.project_mtime < new_mtime:
            debug("Project was modified ({t1} < {t2}), reloading".format(t1=self.project_mtime, t2=new_mtime))
            self.init(None)
            self.project_mtime = new_mtime
        else:
            threading.Timer(self.refresh_interval, self.refresh).start()

    def chmod(self, path, mode):
        self.wait_until_initialized()
        debug('chmod path:%s mode:%s' % (path, mode))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)
        node.mode = mode
        return 0

    def chown(self, path, uid, gid):
        self.wait_until_initialized()
        debug('chown path:%s uid:%s gid:%s' % (path, uid, gid))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)
        node.uid = uid
        node.gid = gid

    def create(self, *args, **kwargs):
        self.wait_until_initialized()
        return self._create(*args, **kwargs)

    def _create(self, path, mode, ctime=None, mtime=None, size=0, dxid=None, describe=None):
        debug('create path:%s mode:%s' % (path, mode))

        if ctime is None:
            ctime = time()
        if mtime is None:
            mtime = time()

        dirname, filename = os.path.split(path)
        parent = self._parent(path)

        if not parent:
            raise FuseOSError(ENOENT)

        if not parent.type == DXInode.DIR:
            raise FuseOSError(ENOTDIR)

        if dxid is None:
            f = dxpy.new_dxfile(name=filename, folder=dirname)
            dxid = f.get_id()
            describe = f.describe()
        node = DXInode(DXInode.FILE, filename, mode | stat.S_IFREG,
                       self.uid, self.gid,
                       ctime=ctime, mtime=mtime, size=size, dxid=dxid, describe=describe)

        debug("Adding file", node.name, "to", parent.name)
        parent.child_nodes[node.name] = node
        #debug("Parent contents:", parent.data)

        # self.files[path] = dict(st_mode=(S_IFREG | mode), st_nlink=1,
        #                         st_size=0, st_ctime=time(), st_mtime=time(),
        #                         st_atime=time())

        self.fd += 1
        return self.fd

    def getattr(self, path, fh=None):
        self.wait_until_initialized()
        debug('getattr path:%s' % path)

        node = self._node(path)
        if not node:
            debug("getattr: path", path, "not found")
            raise FuseOSError(ENOENT)
        else:
            return node.stat()

    def getxattr(self, path, name, position=0):
        self.wait_until_initialized()
        debug('getxattr path:%s name:%s' % (path, name))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)
        elif node.describe is None or node.handler is None:
            raise FuseOSError(ENOENT)

        # The following attributes are "mutable" and need to be fetched
        # anew each time. This whitelist is most useful for allowing
        # fast (no API call needed) access to random properties like
        # "security.selinux" that clients will query for but will never
        # exist in the describe output.
        if name in ('state', 'name', 'folder', 'sponsored', 'tags', 'modified'):
            describe = node.handler.describe()
        else:
            describe = node.describe

        # See also ENOATTR
        value = str(describe.get(name, ''))

        return value

    def listxattr(self, path):
        self.wait_until_initialized()
        debug('listxattr path:%s' % path)

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)

        attrs = [str(attr) for attr in node.describe.keys()]
        debug(attrs)
        return attrs

    def mkdir(self, *args, **kwargs):
        self.wait_until_initialized()
        return self._mkdir(*args, **kwargs)

    def _mkdir(self, path, mode, make_remote=True):
        debug('mkdir path:%s mode:%s' % (path, mode))

        dirname, filename = os.path.split(path)
        parent = self._parent(path)

        if not parent:
            raise FuseOSError(ENOENT)

        if not parent.type == DXInode.DIR:
            raise FuseOSError(ENOTDIR)

        if make_remote:
            self.project.new_folder(path)

        node = DXInode(DXInode.DIR, filename, mode | stat.S_IFDIR, self.uid, self.gid)
        debug("Adding dir", node.name, "to", parent.name)
        parent.child_nodes[node.name] = node
        debug("Parent contents:", parent.child_nodes)

        # self.files[path] = dict(st_mode=(S_IFDIR | mode), st_nlink=2,
        #                         st_size=0, st_ctime=time(), st_mtime=time(),
        #                         st_atime=time())
        # self.files['/']['st_nlink'] += 1

    # def open(self, path, flags):
    #     self.fd += 1
    #     return self.fd

    def read(self, path, size, offset, fh):
        self.wait_until_initialized()
        debug('read path:%s size:%s offset:%s' % (path, size, offset))

        node = self._node(path)

        if not node:
            raise FuseOSError(ENOENT)

        return node.read(offset, size)

    def readdir(self, path, fh):
        self.wait_until_initialized()
        debug('readdir path:%s' % path)

        node = self._node(path)
        debug("reading dir", node.name, len(node.child_nodes), "entries")

        for meta in ['.', '..']:
            debug("yielding", meta)
            yield meta
            #yield fuse.Direntry(meta)
        for child_name in node.child_nodes:
            debug("yielding", child_name)
            yield child_name
            #yield fuse.Direntry(child.name)

    def readlink(self, path):
        self.wait_until_initialized()
        debug('readlink path:%s' % path)

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)

        return node.data

    def removexattr(self, path, name):
        self.wait_until_initialized()
        debug('removexattr path:%s name:%s' % (path, name))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)

        if name in node.xattr:
            del node.xattr[name]
        # See also ENOATTR

    def rename(self, oldpath, newpath):
        self.wait_until_initialized()
        debug('rename oldpath:%s newpath:%s' % (oldpath, newpath))

        old_dirname, old_filename = os.path.split(oldpath)
        new_dirname, new_filename = os.path.split(newpath)
        old_parent = self._parent(oldpath)
        new_parent = self._parent(newpath)
        node       = self._node(oldpath) 

        if not (old_parent or new_parent or node):
            raise FuseOSError(ENOENT)

        if not new_parent.type == DXInode.DIR:
            raise FuseOSError(ENOTDIR)

        node.name = new_filename
        if node.type == DXInode.DIR:
            dxpy.api.project_rename_folder(self.project_id, {"folder": oldpath, "newpath": newpath})
        else:
            if new_dirname != old_dirname:
                self.project.move(new_dirname, [node.handler.get_id()])
            node.handler.rename(new_filename)

        del old_parent.child_nodes[old_filename]
        new_parent.child_nodes[new_filename] = node

    def rmdir(self, path):
        self.wait_until_initialized()
        debug('rmdir path:%s' % path)

        parent      = self._parent(path)
        node        = self._node(path)

        if not (parent or node):
            raise FuseOSError(ENOENT)

        if not node.type == DXInode.DIR:
            raise FuseOSError(ENOTDIR)

        self.project.remove_folder(path)
        del parent.child_nodes[node.name]

    def setxattr(self, path, name, value, flags, position=0):
        self.wait_until_initialized()
        debug('setxattr path:%s name:%s value:%s flags:%s' % (path, name, value, flags))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)

        if name == 'tag':
            node.handler.add_tags([value])
        elif name == 'property':
            prop_name, prop_value = value.split(":")
            node.handler.add_properties({prop_name: prop_value})
        elif name == 'state' and value == 'closed':
            debug("closing", node)
            node.handler.close(block=True)
            node.reload()
            node.size = node.handler.size
            debug("closed", node)
        node.xattr[name] = value

    def statfs(self, path):
        return dict(f_bsize=512, f_blocks=4096, f_bavail=2048)

    def symlink(self, target, source):
        self.wait_until_initialized()
        debug('symlink path:%s newpath:%s' % (target, source))

        source_node = self._node(target)
        filename    = os.path.basename(source)
        parent      = self._parent(source)

        if not (parent or source_node):
            raise FuseOSError(ENOENT)

        if not parent.type == DXInode.DIR:
            raise FuseOSError(ENOTDIR)

        node = DXInode(DXInode.LINK, filename, 0644 | stat.S_IFLNK, self.uid, self.gid)
        node.data = target

        parent.child_nodes[node.name] = node

    def truncate(self, path, length, fh=None):
        self.wait_until_initialized()
        debug('truncate path:%s len:%s' % (path, length))

        node = self._node(path)

        if not node:
            raise FuseOSError(ENOENT)

        node.truncate(length)

    def unlink(self, path):
        self.wait_until_initialized()
        debug('unlink path:%s' % path)

        parent = self._parent(path)
        child  = self._node(path)

        if not (parent or child):
            raise FuseOSError(ENOENT)

        debug("Removing", child.handler)
        child.handler.remove()
        del parent.child_nodes[child.name]

    def utimens(self, path, times=None):
        self.wait_until_initialized()
        debug('utime path:%s times:%s' % (path, times))

        node = self._node(path)
        if not node:
            raise FuseOSError(ENOENT)
        node.ctime = node.mtime = times[0]

    def write(self, path, data, offset, fh):
        self.wait_until_initialized()
        debug('write path:%s buflen:%s offset:%s' % (path, len(data), offset))

        node = self._node(path)

        if not node:
            raise FuseOSError(ENOENT)

        return node.write(offset, data)

    # --- Tree Helpers
    def _node(self, path):
        if path == '/':
            return self.root
        else:
            return self.root.child(path[1:])

    def _parent(self, path):
        parent_path = os.path.dirname(path)
        return self._node(parent_path)

parser = argparse.ArgumentParser(description="DNAnexus FUSE driver")
parser.add_argument("mountpoint", help="Directory to mount the filesystem on")
parser.add_argument("--project-id", help="DNAnexus project ID to mount", default=dxpy.WORKSPACE_ID or '', nargs='?')
parser.add_argument("--debug", action='store_true')
parser.add_argument("--foreground", action='store_true')
args = parser.parse_args()

def debug(*args_to_print):
    if args.debug:
        print(*args_to_print)

if not args.project_id.startswith('project-') and not args.project_id.startswith('container-'):
    parser.exit(3, "Error: A valid project or container ID was not provided for --project-id\n")

if not args.debug:
    sys.stdout = open(os.devnull, 'w')
    sys.stderr = open(os.devnull, 'w')

logging.getLogger().setLevel(logging.DEBUG)
fuse = FUSE(DXFS(project_id=args.project_id),
            args.mountpoint,
            foreground=args.foreground,
            nothreads=True,
            fsname='dnanexus:'+args.project_id,
            subtype='dxfs')
